Restore the AgentProcess a thread already held, rather than clearing it - #1912
Conversation
igordayen
left a comment
There was a problem hiding this comment.
@jasperblues - apparently serious issue. would be very helpful to have hand-written (not test) scenatio as real case. please consider adding to the last test AND into the issue.
Executor - Thread1 - Agent Process.....
And what is behavopr with virtual threads?
Thanks
| // Restores rather than clears: nothing leaks between tasks on a pooled thread, and | ||
| // nothing is wiped when the executor runs the task on the submitting thread, which | ||
| // is already inside a process. | ||
| AgentProcessAccessor.with(agentProcess) { |
There was a problem hiding this comment.
Serious issue, requires rigorous testing. Let's add to post 1.5.0 release, so it can be tested more thoroughly.
|
Two review questions answered: a step-by-step scenario, and virtual threads. The second one narrows the scope of this PR, so I've corrected the description above. Does this affect virtual threads? No — and not the platform default either
Neither can run a task on the thread that submitted it, so neither was ever affected. The clearing this PR replaces was harmless on both. Two tests now pin that ( So reaching the defect requires two things together: It is still worth fixing: sharing the app's executor is a supported, documented option, and a propagation primitive that quietly empties a thread local is the kind of thing that costs somebody a day. The scenario, step by stepSetup:
The damage is done at Time 7 and shows up at Time 9, on a different code path, with nothing logged in between. And because Time 4 only happens when the pool is saturated, the same request succeeds whenever a worker happens to be free — so it is intermittent and load-dependent. With the fix, Time 7 restores P instead of clearing it, and Time 9 sees P. What is unchanged
|
f45c23c to
36026b0
Compare
|
@igordayen — right, (That file does still lack its header on Also: the scenario and the virtual-threads answer are in the comment above, and the timeline is now in the issue as well, as you asked. |
|
@alexheifetz — no objection to holding it until after 1.5.0; sequencing is your call. Two things that may be useful in deciding. Scope is narrower than it first reads. With the default configuration this is unreachable. On testing. Agreed this is the part that has to be right, so the suite is built to be falsifiable rather than confirmatory:
Verification: new suite 12/12; 28/28 across the asyncer and accessor tests repeated 5x for flakiness, stable every run; If there is a specific scenario you want covered before it lands — an IT-level one, or a shape I have not thought of — name it and I will add it. |
|
@jasperblues per my understanding scenario is the edge one, when all application threads are busy. I would enhance documentation with note: use two pools with caution, increase size as needed. For Assistant switch to virtual threads. Thanks |
|
@igordayen — but pool sizing changes how often the task lands on the submitting thread. It doesn't change what happens when it does: Thinking about the end user: better that it never happens than that they think "I should have read the small print" after a production crash. |
ExecutorAsyncer set the AgentProcess thread local on the worker and cleared it in a finally. Clearing is right for a pooled worker that arrived empty, and wrong the moment a task runs on a thread that is already inside a process. An Executor is free to do exactly that. A direct executor always does, and a ThreadPoolExecutor with CallerRunsPolicy does once its queue fills — so this appears under load and not in development. The submitting thread comes back from async() holding no process. Nothing throws at that point. The next AgentProcess.get() returns null, usually a blackboard read some distance away, so it presents as "the blackboard lost my object" with the cause several frames back. That is an expensive afternoon. AgentProcessAccessor gains with(), which saves and restores. reset() stays for the callers that want the old semantics. Tests: the direct-executor shape (plain, throwing, nested, repeated), the CallerRunsPolicy pool that reaches it under queue saturation, and — unchanged and still asserted — the pooled-thread isolation that clearing was protecting, so the fix cannot be "stop cleaning up". Five of the ten fail before this change and pass after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review question worth answering in the suite rather than in a thread: does this affect virtual threads, given the default is a pooled executor? It does not, and neither does the platform default. `threading.shared` is false by default, so AsyncConfiguration builds Embabel's own executor — a cached platform pool, or a thread-per-task executor on virtual threads. A cached pool always hands off to a worker and a thread-per-task executor always starts a new thread, so in neither case is the submitting thread the one that runs the task, and the clearing this PR replaces was harmless there. Two tests, one per executor, each asserting the task really did run on another thread before asserting the caller kept its process. Both pass BEFORE this PR's fix as well as after — which is the claim being pinned. Reaching the defect needs threading.shared=true AND an application executor that can run a task on the submitting thread: CallerRunsPolicy with a bounded queue, or a direct executor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ExecutorAsyncerCallerThreadTest proves the propagation behaviour on an executor it constructs itself. That leaves one thing unproven: that a deployment can arrive at that executor through configuration at all. Reaching it needs threading.shared=true AND an application executor that can run a task on the submitting thread — two settings in different places, neither of which mentions the other. SharedExecutorCallerRunsWiringIT boots the real AsyncConfiguration against a bounded CallerRunsPolicy pool and saturates it, so the wiring and the load shape are covered together. It asserts the precondition — the overflow task genuinely ran on the submitting thread — before asserting the outcome, so it cannot pass vacuously. Against the unfixed code it fails with "expected: <AgentProcess> but was: <null>", after that precondition has passed. A second case pins the other side: same application executor, sharing left at its default of false, and the task runs on a worker instead. That is what makes the first case's title true rather than incidental. Named as an IT and run with the suite from #1577 (mvn -Dtest='*IT,!LLMOllama*IT' -Dsurefire.failIfNoSpecifiedTests=false test). It needs no LLM keys, so it is safe in any environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
36026b0 to
203753f
Compare
|
@igordayen — taking the docs note and the load test. Added On the extra guard — I think it is already there, structurally rather than as configuration. Restore differs from clear only when the calling thread already held a process, which is precisely the caller-runs case; on every other path it restores nothing and behaves exactly as before. A flag would add a configuration in which the defect is still reachable, and a predicate that can itself be wrong. |
|
Please consider adding Tests: with restores previous process after success The last one matters because current code says: if (value == null) { So with(null) does not clear. That is okay if intentional, but the test should pin it. I agree we should not add the shared-executor guard conditions. Docs To Add In This PR Location: embabel-agent-docs/src/main/asciidoc/reference/asynch-mode/page.adoc Place it near the threading.shared explanation. Suggested text: NOTE: Follow-Up After This PR (I can take it): Bare minimum guard work later: Add property: Values: Apply only when sharedExecutor != null. Warn or fail for: Tests for that follow-up: shared=true + CallerRunsPolicy + warn starts and logs warning Please run all IT tests. |
…ts you Review feedback from @igordayen. `AgentProcessAccessorTest` covered getValue/setValue/reset and nothing at all on `with`, so the guarantee this PR exists for was pinned only through the executor - where it fails against the behaviour of a saturated thread pool several layers away rather than against its own rules. Six tests now state it directly: restore after return, restore after a throw, restore at every level on the way out of a nested `with`, and clear when the thread arrived empty. The null case is the one worth having written down. `with(null)` does not clear: a null value means the caller had nothing to propagate, which is what ExecutorAsyncer passes for a task submitted from outside any process, so the block runs against whatever the thread already holds. Intended, easy to read as an oversight, now pinned in both directions. Mutation-checked rather than assumed. Reverting the finally to a bare reset() fails four of them; making with(null) clear fails the two null-value tests. The docs note is Igor's text, next to the threading.shared table. The point it makes is not the AgentProcess bug - that is fixed here - but that sharing couples agent execution to the application executor's backpressure, which survives the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@igordayen — taken all of it. Tests, the docs note, and the follow-up left to you.
|
igordayen
left a comment
There was a problem hiding this comment.
@jasperblues - looks good, thank you
Review feedback from @igordayen. Most of this is documentation of things that read fine if you already know the answer. The nested role lookup now carries the yaml it reads and says what a miss returns; the model selection context says who sets it, how long it lives and why nothing disposes it; the credential cache says what two concurrent requests for one user actually do; allWellKnownLlmNames says that "well known" means named in configuration rather than reachable, which is the whole point of it in a BYOK deployment and is not guessable from the name. RoleResolution.Options wraps LlmOptions because the wrapper is what makes it a case rather than a payload: unwrapped, the platform cannot tell it from the other two answers in a `when`. The Asyncer doc is the one substantive change. It told implementations to restore what the worker held before, which the model selection context does and AgentProcess does not - it still clears, which is #1911, fixed in #1912. Igor was right that the sentence belonged to the other PR. The obligation is worth stating here either way, so it now states it and says plainly which half is which rather than describing a state neither branch is in on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@Jasper, the only thing I would like to get clarity on is the use case. |
#1894) Closes #1889. `embabel.models.llms` maps a role to one model name, and a model belongs to one provider, so a role pins its provider: give a deployment only an Anthropic key and every OpenAI-named role fails. Roles now resolve through a `RoleResolver` SPI, with a nested `embabel.models.roles` shape naming a model per provider, so a role means whatever the active provider makes it mean. Application resolvers are consulted before the platform's own, so an application can override any role and ignore the rest. A role reached through a user's own credential resolves against that provider only and never falls back to the deployment's flat map - a user's key must not silently select a model the deployment pays for. Services built from user keys are cached under a configurable bound. The model selection context travels with work the platform moves off the calling thread, because losing it does not fail loudly: resolution would quietly serve a model the deployment is billed for. The AgentProcess half of that propagation is #1912, reviewed separately. Unresolvable names stay fatal at startup for a keyed deployment and warn for one awaiting a key; the reference docs now carry the full matrix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
igordayen
left a comment
There was a problem hiding this comment.
@jasperblues - how do you want to handle this PR, as your observations during testing do not exatly match prose. thanks
#1894 wrapped the body of async() in ModelSelectionContextHolder.with and reindented it; this branch replaces the same body's clear-on-exit with AgentProcessAccessor.with. Both touch the identical lines, so the merge conflicts even though neither change contradicts the other. Resolved by nesting: main's wrapper stays, and the restore goes inside it in place of the set/try/finally/reset. The two are the same shape - capture on the way in, put back what was there on the way out - which is why they compose without either knowing about the other. Mutation-checked rather than assumed, because the merge rewrote the exact lines the tests cover: restoring the old set/try/finally/reset inside the new wrapper fails five cases in ExecutorAsyncerCallerThreadTest, so the fix survived the resolution rather than being quietly undone by it. 174 tests green across both branches' suites - the caller-thread and accessor tests from this branch, and the model selection propagation and role resolution tests from 1894. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@igordayen — I think I have found the mismatch you meant, and you were right to flag it: the Testing section had gone stale. It described the branch as it stood on 11 Aug, before the two rounds of work you asked for. So the prose named one test class and 12 cases, while the branch had three test files and 20. Nothing in it was false — the numbers were correct on the day they were written, and each round since simply added to the branch without the description being brought forward. Recording old vs new rather than quietly editing:
What was added after that snapshot, both at your request:
Description now matches the branch, with the superseded figures noted rather than erased. The rest of the prose I checked while I was in there, and it holds:
If the mismatch you meant was something else, point me at it and I will fix that too. |
…d in Four cases, all of which fail on the unfixed code, taking that count from five of twelve to nine of sixteen. parallelMap is how an action fans out, and it is where this defect looks least like a bug. Every item goes through async(), so on an executor that can run on the submitter the FIRST item empties the thread and later ones run with no process - one call, partly correct results, nothing thrown. Unfixed, the direct-executor case fails at item 2 and the saturated CallerRunsPolicy case at item 4. A single-async test cannot show that shape, and it is the shape a real deployment meets. Both parallelMap branches are covered because maxConcurrency < size takes the semaphore path, which is separate code. The blackboard case states the symptom rather than the mechanism. Nothing throws when the process is lost; the next read is typically a blackboard access some frames away, so what gets reported is "the blackboard lost my object". Unfixed, that read returns null. The suite now says that in the terms someone would use to report it. One of these asserted outside the withCurrent block on first writing and failed against the FIXED code, which is worth recording: a probe confirmed the process survives all three paths, so the test was wrong rather than the code. Assertions now sit inside the scope they are about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@igordayen — added four more cases, all of which fail on unfixed The gap they close is Unfixed:
Both The fourth states the symptom rather than the mechanism: after an async on the caller's thread, is the blackboard still reachable? Unfixed, that read returns null. The suite now describes the failure in the words someone would use to report it — "the blackboard lost my object" — rather than only in terms of a thread local. Description updated: 24 cases across three files, 40/40 on the asyncer and accessor suites. |
…task Three more cases that fail on the unfixed code, taking the executor-level count to 13 of 21. The IT gained the primitive actions actually fan out with. It drove a single async through the real AsyncConfiguration, which proves a deployment can reach the caller thread but not what happens when it reaches it via parallelMap - the path OperationContext.parallelMap and parallel actions take. Sixteen items over a one-worker pool, so some are certain to run on the submitter. A failing item in a fan-out is the ordinary case, not an exotic one: one tool call of several throwing does not abandon the request. Unfixed, the caller comes out of that holding no process, so the error handling that runs next is the code that discovers the blackboard is empty. Failure and loss of context arriving together is the worst version of this, because the exception looks like the whole story. The leak case pins the direction of the restore. Putting back what the CALLER had is not the same as leaving whatever the task last set, and a test asserting only "not null" would pass on an implementation that handed the task's process upward - work attributed to, and billed against, the wrong process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@igordayen — three more cases, pushed as The IT gained the primitive actions actually fan out with. It drove a single A failing item in a fan-out. One tool call of several throwing is ordinary, not exotic. Unfixed, the caller comes out of it holding no process — so the error handling that runs next is the code that discovers the blackboard is empty. Failure and loss of context arriving together is the worst version of this bug, because the exception looks like the whole story. A task that sets its own process. This one pins the direction of the restore rather than its presence. Putting back what the caller had is not the same as leaving whatever the task last set, and a test asserting only "not null" would pass on an implementation that handed the task's process upward — work attributed to, and billed against, the wrong process. Counts taken from the surefire XML rather than the summary lines, since reruns make those misleading. Description updated: 27 cases across three files, 43/43 green. |
|
@igordayen — answering "how do you want to handle this PR" with something checkable rather than more prose. #1930 carries the tests from this PR and nothing else — no change to
Both steps are two commands, in the description. Roughly two minutes, no keys needed. The 8 that pass in step 1 matter as much as the 13 that fail. One thing deliberately left out of #1930: On handling: no urgency from me. #1894 is merged and I am unblocked, so this can sit as long as you like. If the reproduction is what was missing, #1930 is it; if the prose is still the problem, point at the line and I will fix or cut it. |
|
|
A few minor observations to consider:
No functional changes needed. |
|
@jasperblues - critical IT tests in |



Fixes #1911.
ExecutorAsyncerset theAgentProcessthread local on the worker and cleared it in afinally. Clearing is right for a pooled worker that arrived empty, and wrong the moment a task runs on a thread that is already inside a process — the submitting thread then comes back fromasync()holding no process.Nothing throws at that point. The next
AgentProcess.get()returns null, usually a blackboard read some distance away, so it presents as "the blackboard lost my object" with the cause several frames back.AgentProcessAccessorgainswith(), which saves and restores.reset()stays for the callers that want the old semantics.Who is affected — narrower than it first looks
Neither the default configuration nor virtual threads can hit this bug. (Said badly in an earlier revision as "not the default, and not virtual threads", which read to at least one reviewer as a claim about what we run on. It is not — it is about which executors the defect can occur on. Embabel and assistant both use virtual threads.)
threading.sharedis false by default, soAsyncConfigurationbuilds Embabel's own executor:newCachedThreadPoolon platform threads,newThreadPerTaskExecutoron virtual. Neither can run a task on the submitting thread, so neither was ever affected.Reaching the defect needs both:
embabel.agent.platform.threading.shared=true, AND an application executor that can run a task on the submitting thread —CallerRunsPolicywith a bounded queue, or a direct executor. Out of the box, nobody hits this.Worth fixing anyway: sharing the app's executor is a supported option, and a propagation primitive that quietly empties a thread local costs whoever meets it a day.
A step-by-step walkthrough of how it goes wrong is in the comment below. To be explicit, since the walkthrough reads like an incident report and is not one: no incident prompted this PR. There was no saturated pool and no observed failure. It was found by reading
ExecutorAsyncerwhile working on the roles SPI (#1889), and the walkthrough is a constructed scenario whose preconditions are stated in its first line.Testing
Three test files, twenty-seven cases.
ExecutorAsyncerCallerThreadTestis eighteen of them, in four groups:CallerRunsPolicy, saturated so the third task overflows onto the caller. Asserts the precondition (the task really did run on the submitting thread) before asserting the outcome, so it cannot pass vacuously.parallelMapgives every worker the caller's process while leaving none behind. This is what stops the fix being "just stop cleaning up".Thirteen of the twenty-one executor-level cases fail on unfixed
mainand pass after — 11 of the 18 inExecutorAsyncerCallerThreadTest, and 2 of the 3 in the wiring IT. The rest are boundary cases that are supposed to pass both ways, and each was run against unfixedmainto confirm it is not vacuous.The ones worth reading, because they fail in ways a single-async test cannot express:
parallelMapon a caller-running executor — where the defect looks least like a bug. Every item goes throughasync(), so a clear on the way out empties the submitting thread partway through its OWN fan-out: later items run with no process while earlier ones succeeded, from one call, with nothing thrown. Unfixed, the direct-executor case fails at item 2 and the saturatedCallerRunsPolicycase at item 4. Covered on both branches, sincemaxConcurrency < sizetakes the separate semaphore path, and now through the real wiring in the IT as well —parallelMapis the pathOperationContext.parallelMapand parallel actions take.Reproduction, independent of this PR: #1930 carries these tests and nothing else, on top of
main. 13 of 21 fail there; 31 of 31 pass once this branch is merged on top. Two commands, in that PR's description.The 8 that pass on
maincarry as much weight as the 13 that fail.OwnedExecutorsNeverRunOnTheCallergreen before and after is the evidence for "virtual threads cannot hit this";PooledThreadIsolationgreen before and after is the evidence that this is not "just stop cleaning up". A suite where everything went red would be proving too much.AgentProcessAccessorTest.Withis mutation-checked separately, at thewith()level: reverting thefinallyto a barereset()fails four of its six, and makingwith(null)clear fails the other two.Verification run, on
d31c62880:embabel-agent-apimodule: 4098 tests, 0 failures, 0 errorsEarlier revisions of this section quoted 12 cases, 28/28 and 4019 tests. Those were correct on 11 Aug and are simply older than the branch — see the comment below for what changed and when.
Follow-up, not in this PR
AgentProcessis a bareThreadLocalpropagated by hand in exactly one class, so it reaches only the threads the platform starts throughAsyncer— not application threads. Micrometer's context-propagation is already a dependency and already used in this same method for observations. Registering it as aThreadLocalAccessoron the globalContextRegistrywould propagate it at every micrometer-aware boundary (Reactor, Spring AI's reactive chains,ContextPropagatingTaskDecorator) and let the hand-rolled capture/restore go away. Worth its own issue.🤖 Generated with Claude Code